昨天我們知道 Java API Client 需要哪些資料來建構搜尋請求。並設計一些方法,用來產生代表搜尋條件及排序方式的物件。而接下來兩天的目標,是能透過 REST API 接收 query string,並做到整合這些程式物件,實現搜尋功能。
這兩天文章的內容偏向實際應用,已經沒有關於 Java API Client 的新的知識點了。
以下在 controller 提供 GET /students 的 API,它會接收多個 query string,並送往 StudentEsRepository 進行搜尋。
@RestController
public class EsController {
@Autowired
private StudentEsRepository studentEsRepository;
@GetMapping("/students")
public ResponseEntity<List<Student>> search(@ModelAttribute StudentRequestParameter param) {
var students = studentEsRepository.find(param);
return ResponseEntity.ok(students);
}
}
在 Spring 的 controller 接收一個 query string,會使用叫做
@RequestParam的 annotation。為了統一名稱,在範例程式中會以「param」或「parameter」等字眼,來命名承載 query string 的類別、物件與參數。
以下的類別描述這支 API 接受的基本 query string。包含用來搜尋的文字,以及排序、分頁的方式。
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.annotation.JsonIgnore;
public class RequestParameter {
@JsonIgnore
private String searchText;
@JsonIgnore
private Integer from;
@JsonIgnore
private Integer size;
@JsonIgnore
private List<String> sortFields;
@JsonIgnore
private List<String> sortOrders;
public Map<String, Object> getCustomizedParamMap() {
return new ObjectMapper().convertValue(
this,
new TypeReference<Map<String, Object>>() {}
);
}
// getter, setter ...
}
另外還提供一個名為 getCustomizedParamMap 的方法,目的是將子類別自定義的 query string 轉為 Map(故搭配使用 @JsonIgnore 的 annotation。)。轉為 Map 是為了在 AbstractEsRepository 達到「泛用」,在第二節會用到。
以下是專門用來搜尋學生的 query string 類別,目前只接收年級參數 grade。它還繼承了來自 RequestParameter 的基本參數。在明天的文章,我們可以攜帶更多種 query string。
public class StudentRequestParameter extends RequestParameter {
private Integer grade;
// getter, setter ...
}
認識完本文的 REST API,讓我們來看一個範例。
GET http://localhost:8080/students
?grade=2
&sortFields=conductScore,name
&sortOrder=desc,asc
&from=0
&size=30
以上 query string 的搜尋條件為:
讓我們回顧一下昨天是如何建構出 ElasticsearchClient 所接收的搜尋請求。
public abstract class AbstractEsRepository<T extends EsDocument> {
// ...
public List<T> search(BoolQuery boolQuery, List<SortOptions> sortOptions, Integer from, Integer size) {
var searchReq = new SearchRequest.Builder()
.index(indexName)
.query(boolQuery._toQuery())
.sort(sortOptions)
.from(from)
.size(size)
.build();
// ...
}
}
而本文的目標,就是用第一節在 controller 所接收的 query string,產生正確的 BoolQuery 與 SortOptions,用以建立 SearchRequest。
public abstract class AbstractEsRepository<T extends EsDocument> {
// ...
public List<T> search(RequestParameter param) {
SearchRequest.Builder searchBuilder = new SearchRequest.Builder();
BoolQuery.Builder queryBuilder = new BoolQuery.Builder();
Map<String, Object> customizedParamMap = param.getCustomizedParamMap();
// TODO
var searchReq = searchBuilder
.query(queryBuilder.build()._toQuery())
.build();
// ...
}
}
上面新寫的 search 方法中,宣告了一個 BoolQuery.Builder 物件,用來承載所有的搜尋條件。從接下來的小節開始,將會逐一將條件附加在它身上,慢慢完成這支搜尋程式。
為了知道「學生」這個 ES document,有哪些欄位可以被搜尋,所以在 AbstractEsRepository 宣告一個抽象方法。藉此讓子類別的 StudentEsRepository 提供欄位名稱。
import org.springframework.util.StringUtils;
public abstract class AbstractEsRepository<T extends EsDocument> {
// ...
protected abstract Set<String> getSearchableFields();
public List<T> search(RequestParameter param) {
SearchRequest.Builder searchBuilder = new SearchRequest.Builder();
BoolQuery.Builder queryBuilder = new BoolQuery.Builder();
Map<String, Object> customizedParamMap = param.getCustomizedParamMap();
// 全文檢索
assignSearchText(param.getSearchText(), queryBuilder);
// ...
}
private void assignSearchText(String searchText, BoolQuery.Builder builder) {
if (StringUtils.hasText(searchText)) {
Set<String> fields = getSearchableFields();
Query matchQuery = SearchUtils.createMatchQuery(fields, searchText);
builder.must(matchQuery);
}
}
}
全文檢索對應的 query string 為 searchText,以上的程式從 RequestParameter 取出該值,建立出 MatchQuery 物件。
而以下是子類別自行定義可以被搜尋的欄位名稱,包含學生的名字(name)與自我介紹(introduction)。
public class StudentEsRepository extends AbstractEsRepository<Student> {
// ...
@Override
protected Set<String> getSearchableFields() {
return Set.of("name", "introduction");
}
}
接著讓我們處理排序的參數。排序欄位所對應的 query string 叫做 sortFields,而排序方向則為 sortOrders。為了支援多重排序,故兩者的資料型態均為 List<String>,且數量應一致,也就是一對一對的。
import org.springframework.util.CollectionUtils;
public abstract class AbstractEsRepository<T extends EsDocument> {
// ...
public List<T> search(RequestParameter param) {
SearchRequest.Builder searchBuilder = new SearchRequest.Builder();
BoolQuery.Builder queryBuilder = new BoolQuery.Builder();
Map<String, Object> customizedParamMap = param.getCustomizedParamMap();
// 排序
assignSortOptions(param.getSortFields(), param.getSortOrders(), searchBuilder);
// 全文檢索 ...
// ...
}
private void assignSortOptions(List<String> sortFields, List<String> sortOrders, SearchRequest.Builder builder) {
if (CollectionUtils.isEmpty(sortFields) || CollectionUtils.isEmpty(sortOrders)) {
return;
}
var optionSize = Math.min(sortFields.size(), sortOrders.size());
var sortOptionList = new ArrayList<SortOptions>();
for (var i = 0; i < optionSize; i++) {
var sortOption = SearchUtils.createSortOption(
sortFields.get(i), sortOrders.get(i));
sortOptionList.add(sortOption);
}
builder.sort(sortOptionList);
}
}
從 RequestParameter 取出排序相關的 query string 後,會建立出一至多個 SortOptions 物件,並賦予給 SearchRequest。此外還考慮到排序的參數有未成對的情形,此時便依照最小成對數(optionSize)來排序。
分頁相關的 query string,處理方式就單純許多。只要取出後再放到 SearchRequest 即可,筆者就不贅述。
public abstract class AbstractEsRepository<T extends EsDocument> {
// ...
public List<T> search(RequestParameter param) {
SearchRequest.Builder searchBuilder = new SearchRequest.Builder();
BoolQuery.Builder queryBuilder = new BoolQuery.Builder();
Map<String, Object> customizedParamMap = param.getCustomizedParamMap();
// 排序 ...
// 分頁
searchBuilder
.from(param.getFrom())
.size(param.getSize());
// 全文檢索 ...
// ...
}
}
前面兩個小節取出了有關全文檢索、排序與分頁的基本 query string,它們在程式中被定義在 RequestParameter 裡。不過我們仍可在子類別的 StudentRequestParameter 自定義其他的 query string。像第一節就加入了代表年級的 grade。
在本文,我們將自定義的 query string,預設視為「相等條件」,故建立出 TermQuery 物件。
public abstract class AbstractEsRepository<T extends EsDocument> {
// ...
public List<T> search(RequestParameter param) {
SearchRequest.Builder searchBuilder = new SearchRequest.Builder();
BoolQuery.Builder queryBuilder = new BoolQuery.Builder();
Map<String, Object> customizedParamMap = param.getCustomizedParamMap();
// 排序、分頁、全文檢索...
// 相等條件
assignEqualCondition(customizedParamMap, queryBuilder);
// ...
}
private void assignEqualCondition(Map<String, Object> paramMap, BoolQuery.Builder builder) {
paramMap.forEach((key, value) -> {
if (value != null) {
Query query = SearchUtils.createTermQuery(key, value);
builder.filter(query);
}
});
}
}
假設 document 結構如下。
{
"id": "100",
"name": "Vincent",
"grade": 2,
"conductScore": 85,
"englishCertificate": {
"type": "TOEIC",
"issuedDate": "2023-01-01"
}
}
則 grade=2 這樣子的 query string,會在上面的程式中,被當作要搜尋 grade 欄位值為 2 的 document。
以上就是針對特定欄位值,以相等條件來搜尋的做法。
那麼接下來,我們還有一些形式的搜尋條件尚未處理到。
conductScore 的值在「80 ~ 90」之間。englishCertificate.type 的值為「TOEIC」。englishCertificateType,但太長了,query string 想取名為 engCertType,比較簡短。這些問題會在明天的文章來解決。
Elasticsearch 系列的完成後專案,會在鐵人賽結束後上傳到 Github,並轉貼到這裡。
今日文章到此結束!
最後推廣一下自己的部落格,我是「新手工程師的程式教室」的作者,請多指教![]()